@cleartrip/frontguard 0.2.2 → 0.2.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +475 -50
- package/dist/cli.js.map +1 -1
- package/package.json +4 -4
- package/templates/checks-snapshot-bitbucket-snippet.yml +30 -0
- package/templates/freekit-ci-setup.md +21 -0
package/dist/cli.js
CHANGED
|
@@ -6,13 +6,14 @@ import * as tty from 'tty';
|
|
|
6
6
|
import { WriteStream } from 'tty';
|
|
7
7
|
import path5, { sep, normalize, delimiter, resolve, dirname } from 'path';
|
|
8
8
|
import fs from 'fs/promises';
|
|
9
|
-
import {
|
|
9
|
+
import { pathToFileURL, fileURLToPath } from 'url';
|
|
10
10
|
import { execFileSync, spawn } from 'child_process';
|
|
11
11
|
import { createRequire } from 'module';
|
|
12
12
|
import fs2 from 'fs';
|
|
13
13
|
import { pipeline } from 'stream/promises';
|
|
14
14
|
import { PassThrough } from 'stream';
|
|
15
15
|
import fg from 'fast-glob';
|
|
16
|
+
import * as ts from 'typescript';
|
|
16
17
|
|
|
17
18
|
var __create = Object.create;
|
|
18
19
|
var __defProp = Object.defineProperty;
|
|
@@ -2497,6 +2498,11 @@ async function initFrontGuard(cwd) {
|
|
|
2497
2498
|
}
|
|
2498
2499
|
|
|
2499
2500
|
// src/ci/bitbucket-pr-snippet.ts
|
|
2501
|
+
function checksImageMarkdown() {
|
|
2502
|
+
const u4 = process.env.FRONTGUARD_CHECKS_IMAGE_URL?.trim();
|
|
2503
|
+
if (!u4) return null;
|
|
2504
|
+
return ``;
|
|
2505
|
+
}
|
|
2500
2506
|
function bitbucketPipelineResultsUrl() {
|
|
2501
2507
|
const full = process.env.BITBUCKET_REPO_FULL_NAME?.trim();
|
|
2502
2508
|
const bn = process.env.BITBUCKET_BUILD_NUMBER?.trim();
|
|
@@ -2521,8 +2527,12 @@ function bitbucketDownloadsPageUrl() {
|
|
|
2521
2527
|
function formatBitbucketPrSnippet(report) {
|
|
2522
2528
|
const publicReport = process.env.FRONTGUARD_PUBLIC_REPORT_URL?.trim();
|
|
2523
2529
|
const linkOnly = process.env.FRONTGUARD_BITBUCKET_COMMENT_LINK_ONLY === "1";
|
|
2530
|
+
const imgMd = checksImageMarkdown();
|
|
2524
2531
|
if (linkOnly && publicReport) {
|
|
2525
|
-
|
|
2532
|
+
const parts = [];
|
|
2533
|
+
if (imgMd) parts.push(imgMd, "");
|
|
2534
|
+
parts.push(publicReport.endsWith("\n") ? publicReport.slice(0, -1) : publicReport);
|
|
2535
|
+
return `${parts.join("\n")}
|
|
2526
2536
|
`;
|
|
2527
2537
|
}
|
|
2528
2538
|
const downloadsName = process.env.FRONTGUARD_REPORT_DOWNLOAD_NAME?.trim();
|
|
@@ -2537,16 +2547,27 @@ function formatBitbucketPrSnippet(report) {
|
|
|
2537
2547
|
(n3, r4) => n3 + r4.findings.filter((f4) => f4.severity === "warn").length,
|
|
2538
2548
|
0
|
|
2539
2549
|
);
|
|
2540
|
-
const out = [
|
|
2550
|
+
const out = [];
|
|
2551
|
+
if (imgMd) {
|
|
2552
|
+
out.push(imgMd);
|
|
2553
|
+
out.push("");
|
|
2554
|
+
}
|
|
2555
|
+
out.push(
|
|
2541
2556
|
"FrontGuard report (short summary)",
|
|
2542
2557
|
"",
|
|
2543
2558
|
`Risk: ${riskScore} | Blocking: ${blocks} | Warnings: ${warns}`,
|
|
2544
2559
|
""
|
|
2545
|
-
|
|
2560
|
+
);
|
|
2546
2561
|
if (publicReport) {
|
|
2547
2562
|
out.push("Full interactive report (open in browser):");
|
|
2548
2563
|
out.push(publicReport);
|
|
2549
2564
|
out.push("");
|
|
2565
|
+
if (imgMd) {
|
|
2566
|
+
out.push(
|
|
2567
|
+
"The image above is a quick checks overview. Use the link for file-level findings, hints, and suggested fixes."
|
|
2568
|
+
);
|
|
2569
|
+
out.push("");
|
|
2570
|
+
}
|
|
2550
2571
|
} else if (downloadsName && downloadsPage) {
|
|
2551
2572
|
out.push("HTML report is in Repository \u2192 Downloads. Open this page while logged in:");
|
|
2552
2573
|
out.push(downloadsPage);
|
|
@@ -2953,9 +2974,9 @@ async function detectStack(cwd) {
|
|
|
2953
2974
|
try {
|
|
2954
2975
|
const tsconfigPath = path5.join(cwd, "tsconfig.json");
|
|
2955
2976
|
const tsRaw = await fs.readFile(tsconfigPath, "utf8");
|
|
2956
|
-
const
|
|
2957
|
-
if (typeof
|
|
2958
|
-
tsStrict =
|
|
2977
|
+
const ts2 = JSON.parse(tsRaw);
|
|
2978
|
+
if (typeof ts2.compilerOptions?.strict === "boolean") {
|
|
2979
|
+
tsStrict = ts2.compilerOptions.strict;
|
|
2959
2980
|
}
|
|
2960
2981
|
} catch {
|
|
2961
2982
|
}
|
|
@@ -5013,6 +5034,178 @@ async function runCustomRules(cwd, config, restrictToFiles) {
|
|
|
5013
5034
|
durationMs: Math.round(performance.now() - t0)
|
|
5014
5035
|
};
|
|
5015
5036
|
}
|
|
5037
|
+
var BIG_LITERAL_MIN = 9e3;
|
|
5038
|
+
function scanDiscardedExpensiveCalls(regionText, fileNameHint, regionStartLine) {
|
|
5039
|
+
const kind = /\.(tsx|jsx)$/i.test(fileNameHint) ? ts.ScriptKind.TSX : ts.ScriptKind.TS;
|
|
5040
|
+
const sf = ts.createSourceFile(
|
|
5041
|
+
fileNameHint,
|
|
5042
|
+
regionText,
|
|
5043
|
+
ts.ScriptTarget.Latest,
|
|
5044
|
+
true,
|
|
5045
|
+
kind
|
|
5046
|
+
);
|
|
5047
|
+
const parseDiags = sf.parseDiagnostics;
|
|
5048
|
+
if (parseDiags && parseDiags.length > 12) {
|
|
5049
|
+
return [];
|
|
5050
|
+
}
|
|
5051
|
+
const stack = [/* @__PURE__ */ new Map()];
|
|
5052
|
+
const lines = [];
|
|
5053
|
+
function current() {
|
|
5054
|
+
return stack[stack.length - 1];
|
|
5055
|
+
}
|
|
5056
|
+
function lookupExpensive(name) {
|
|
5057
|
+
for (let i3 = stack.length - 1; i3 >= 0; i3--) {
|
|
5058
|
+
const v3 = stack[i3].get(name);
|
|
5059
|
+
if (v3 !== void 0) return v3;
|
|
5060
|
+
}
|
|
5061
|
+
return false;
|
|
5062
|
+
}
|
|
5063
|
+
function lineOf(node) {
|
|
5064
|
+
const lc = ts.getLineAndCharacterOfPosition(sf, node.getStart(sf, false));
|
|
5065
|
+
return regionStartLine + lc.line;
|
|
5066
|
+
}
|
|
5067
|
+
function visitFunctionLike(fn) {
|
|
5068
|
+
stack.push(/* @__PURE__ */ new Map());
|
|
5069
|
+
for (const p2 of fn.parameters) {
|
|
5070
|
+
if (ts.isIdentifier(p2.name)) current().set(p2.name.text, false);
|
|
5071
|
+
}
|
|
5072
|
+
const body = fn.body;
|
|
5073
|
+
if (!body) {
|
|
5074
|
+
stack.pop();
|
|
5075
|
+
return;
|
|
5076
|
+
}
|
|
5077
|
+
if (ts.isBlock(body)) {
|
|
5078
|
+
for (const st of body.statements) visitStmt(st);
|
|
5079
|
+
} else if (ts.isExpression(body)) {
|
|
5080
|
+
visitStmt(ts.factory.createExpressionStatement(body));
|
|
5081
|
+
}
|
|
5082
|
+
stack.pop();
|
|
5083
|
+
}
|
|
5084
|
+
function visitBlock(block) {
|
|
5085
|
+
stack.push(/* @__PURE__ */ new Map());
|
|
5086
|
+
for (const st of block.statements) visitStmt(st);
|
|
5087
|
+
stack.pop();
|
|
5088
|
+
}
|
|
5089
|
+
function visitMaybeBlock(s3) {
|
|
5090
|
+
if (ts.isBlock(s3)) visitBlock(s3);
|
|
5091
|
+
else visitStmt(s3);
|
|
5092
|
+
}
|
|
5093
|
+
function visitStmt(st) {
|
|
5094
|
+
if (ts.isVariableStatement(st)) {
|
|
5095
|
+
for (const decl of st.declarationList.declarations) {
|
|
5096
|
+
if (!ts.isIdentifier(decl.name) || !decl.initializer) continue;
|
|
5097
|
+
const name = decl.name.text;
|
|
5098
|
+
const init3 = decl.initializer;
|
|
5099
|
+
if (ts.isArrowFunction(init3) || ts.isFunctionExpression(init3)) {
|
|
5100
|
+
current().set(name, isBodyExpensive(init3.body));
|
|
5101
|
+
visitFunctionLike(init3);
|
|
5102
|
+
}
|
|
5103
|
+
}
|
|
5104
|
+
return;
|
|
5105
|
+
}
|
|
5106
|
+
if (ts.isFunctionDeclaration(st) && st.name && st.body) {
|
|
5107
|
+
current().set(st.name.text, isBodyExpensive(st.body));
|
|
5108
|
+
visitFunctionLike(st);
|
|
5109
|
+
return;
|
|
5110
|
+
}
|
|
5111
|
+
if (ts.isExpressionStatement(st)) {
|
|
5112
|
+
const ex = st.expression;
|
|
5113
|
+
if (ts.isCallExpression(ex) && !ex.questionDotToken && ts.isIdentifier(ex.expression) && lookupExpensive(ex.expression.text)) {
|
|
5114
|
+
lines.push(lineOf(ex));
|
|
5115
|
+
}
|
|
5116
|
+
return;
|
|
5117
|
+
}
|
|
5118
|
+
if (ts.isBlock(st)) {
|
|
5119
|
+
visitBlock(st);
|
|
5120
|
+
return;
|
|
5121
|
+
}
|
|
5122
|
+
if (ts.isIfStatement(st)) {
|
|
5123
|
+
visitMaybeBlock(st.thenStatement);
|
|
5124
|
+
if (st.elseStatement) visitMaybeBlock(st.elseStatement);
|
|
5125
|
+
return;
|
|
5126
|
+
}
|
|
5127
|
+
if (ts.isForStatement(st) || ts.isForOfStatement(st) || ts.isForInStatement(st) || ts.isWhileStatement(st) || ts.isDoStatement(st)) {
|
|
5128
|
+
visitMaybeBlock(st.statement);
|
|
5129
|
+
return;
|
|
5130
|
+
}
|
|
5131
|
+
if (ts.isTryStatement(st)) {
|
|
5132
|
+
visitBlock(st.tryBlock);
|
|
5133
|
+
if (st.catchClause?.block) visitBlock(st.catchClause.block);
|
|
5134
|
+
if (st.finallyBlock) visitBlock(st.finallyBlock);
|
|
5135
|
+
return;
|
|
5136
|
+
}
|
|
5137
|
+
if (ts.isSwitchStatement(st)) {
|
|
5138
|
+
for (const clause of st.caseBlock.clauses) {
|
|
5139
|
+
for (const s3 of clause.statements) visitStmt(s3);
|
|
5140
|
+
}
|
|
5141
|
+
return;
|
|
5142
|
+
}
|
|
5143
|
+
if (ts.isClassDeclaration(st) && st.members) {
|
|
5144
|
+
for (const member of st.members) {
|
|
5145
|
+
if (ts.isMethodDeclaration(member) && member.body) {
|
|
5146
|
+
const nm = member.name;
|
|
5147
|
+
if (ts.isIdentifier(nm)) {
|
|
5148
|
+
current().set(nm.text, isBodyExpensive(member.body));
|
|
5149
|
+
}
|
|
5150
|
+
visitFunctionLike(member);
|
|
5151
|
+
}
|
|
5152
|
+
}
|
|
5153
|
+
}
|
|
5154
|
+
}
|
|
5155
|
+
for (const st of sf.statements) {
|
|
5156
|
+
if (ts.isImportDeclaration(st) || ts.isImportEqualsDeclaration(st)) continue;
|
|
5157
|
+
if (ts.isExportDeclaration(st)) continue;
|
|
5158
|
+
if (ts.isExportAssignment(st)) {
|
|
5159
|
+
const ex = st.expression;
|
|
5160
|
+
if (ts.isArrowFunction(ex) || ts.isFunctionExpression(ex)) {
|
|
5161
|
+
visitFunctionLike(ex);
|
|
5162
|
+
}
|
|
5163
|
+
continue;
|
|
5164
|
+
}
|
|
5165
|
+
visitStmt(st);
|
|
5166
|
+
}
|
|
5167
|
+
return dedupeSorted(lines);
|
|
5168
|
+
}
|
|
5169
|
+
function dedupeSorted(nums) {
|
|
5170
|
+
return [...new Set(nums)].sort((a3, b3) => a3 - b3);
|
|
5171
|
+
}
|
|
5172
|
+
function isBodyExpensive(body) {
|
|
5173
|
+
if (!body) return false;
|
|
5174
|
+
if (ts.isBlock(body)) return blockHasExpensiveLoop(body);
|
|
5175
|
+
return nodeHasExpensiveLoop(body);
|
|
5176
|
+
}
|
|
5177
|
+
function blockHasExpensiveLoop(block) {
|
|
5178
|
+
return nodeHasExpensiveLoop(block);
|
|
5179
|
+
}
|
|
5180
|
+
function nodeHasExpensiveLoop(node) {
|
|
5181
|
+
let found = false;
|
|
5182
|
+
const visit = (n3) => {
|
|
5183
|
+
if (found) return;
|
|
5184
|
+
if (ts.isForStatement(n3) || ts.isForOfStatement(n3) || ts.isForInStatement(n3) || ts.isWhileStatement(n3) || ts.isDoStatement(n3)) {
|
|
5185
|
+
if (subtreeHasBigNumeric(n3, BIG_LITERAL_MIN)) found = true;
|
|
5186
|
+
return;
|
|
5187
|
+
}
|
|
5188
|
+
ts.forEachChild(n3, visit);
|
|
5189
|
+
};
|
|
5190
|
+
visit(node);
|
|
5191
|
+
return found;
|
|
5192
|
+
}
|
|
5193
|
+
function subtreeHasBigNumeric(node, min) {
|
|
5194
|
+
let found = false;
|
|
5195
|
+
const visit = (n3) => {
|
|
5196
|
+
if (found) return;
|
|
5197
|
+
if (ts.isNumericLiteral(n3)) {
|
|
5198
|
+
const v3 = Number(n3.text.replace(/_/g, ""));
|
|
5199
|
+
if (Number.isFinite(v3) && v3 >= min) {
|
|
5200
|
+
found = true;
|
|
5201
|
+
return;
|
|
5202
|
+
}
|
|
5203
|
+
}
|
|
5204
|
+
ts.forEachChild(n3, visit);
|
|
5205
|
+
};
|
|
5206
|
+
visit(node);
|
|
5207
|
+
return found;
|
|
5208
|
+
}
|
|
5016
5209
|
|
|
5017
5210
|
// src/lib/ai-decorators.ts
|
|
5018
5211
|
var FILE_SCAN_HEAD_LINES = 40;
|
|
@@ -5183,6 +5376,21 @@ var PATTERNS2 = [
|
|
|
5183
5376
|
id: "ai-sql-template",
|
|
5184
5377
|
re: /(?:query|execute|raw)\s*\(\s*[`'"][^`'"]*\$\{/i,
|
|
5185
5378
|
message: "Possible dynamic SQL/string \u2014 ensure parameterization, not string concat."
|
|
5379
|
+
},
|
|
5380
|
+
/**
|
|
5381
|
+
* Classic C-style loop with `<= ...length` — often an off-by-one with `charAt(i)` / array indexing
|
|
5382
|
+
* (index `length` is out of range). Prefer `< length`, `for...of`, or `Array.from`.
|
|
5383
|
+
*/
|
|
5384
|
+
{
|
|
5385
|
+
id: "ai-for-lte-length",
|
|
5386
|
+
re: /for\s*\(\s*(?:let|var|const)\s+\w+\s*=\s*\d+\s*;\s*\w+\s*<=\s*[^;)]+\.length\s*;/,
|
|
5387
|
+
message: "Loop condition uses `<= ...length` \u2014 often an extra iteration (e.g. `charAt(length)` is empty). Prefer `< ...length` or a safer iteration style."
|
|
5388
|
+
},
|
|
5389
|
+
/** AI often pastes broad eslint suppression without review. */
|
|
5390
|
+
{
|
|
5391
|
+
id: "ai-eslint-disable",
|
|
5392
|
+
re: /eslint-disable(?:-next-line|-line)?\b/i,
|
|
5393
|
+
message: "ESLint disable in AI-marked code \u2014 confirm the rule violation is understood and cannot be fixed properly."
|
|
5186
5394
|
}
|
|
5187
5395
|
];
|
|
5188
5396
|
function scanRegion(rel, regionText, regionStartLine, gate, tag, findings) {
|
|
@@ -5198,6 +5406,16 @@ function scanRegion(rel, regionText, regionStartLine, gate, tag, findings) {
|
|
|
5198
5406
|
});
|
|
5199
5407
|
}
|
|
5200
5408
|
}
|
|
5409
|
+
const astLines = scanDiscardedExpensiveCalls(regionText, rel, regionStartLine);
|
|
5410
|
+
for (const line of astLines) {
|
|
5411
|
+
findings.push({
|
|
5412
|
+
id: "ai-discarded-expensive-call",
|
|
5413
|
+
severity: sev(gate),
|
|
5414
|
+
message: `${tag} Call discards the return value of a local function whose body includes a loop with a very large numeric bound \u2014 likely wasted CPU on every run (e.g. remove the call or move work to an effect / memo with a real dependency).`,
|
|
5415
|
+
file: rel,
|
|
5416
|
+
detail: `line ${line}`
|
|
5417
|
+
});
|
|
5418
|
+
}
|
|
5201
5419
|
}
|
|
5202
5420
|
async function runAiAssistedStrict(cwd, config, pr) {
|
|
5203
5421
|
const t0 = performance.now();
|
|
@@ -5321,6 +5539,26 @@ function escapeHtml(s3) {
|
|
|
5321
5539
|
return s3.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
5322
5540
|
}
|
|
5323
5541
|
|
|
5542
|
+
// src/report/check-descriptions.ts
|
|
5543
|
+
var CHECK_DESCRIPTIONS = {
|
|
5544
|
+
eslint: "Runs ESLint using your project config. Flags style, correctness, and framework-specific issues in the repo or PR-scoped files.",
|
|
5545
|
+
prettier: "Checks that files match Prettier formatting. Catches unformatted edits so the codebase stays consistent.",
|
|
5546
|
+
typescript: "Runs the TypeScript compiler (tsc --noEmit) on the project. Surfaces type errors before merge.",
|
|
5547
|
+
secrets: "Scans changed files for patterns that look like leaked secrets (tokens, keys). Heuristic \u2014 review each hit.",
|
|
5548
|
+
cycles: "Runs madge for circular dependencies on TypeScript/JavaScript entry points. Import cycles can cause brittle builds and load order bugs.",
|
|
5549
|
+
"dead-code": "Runs ts-prune to find unused exports in the TypeScript project. Helps trim dead surface area.",
|
|
5550
|
+
bundle: "Measures total size of configured build artifacts (glob) and compares to a checked-in baseline. Flags large regressions in shipped JS/CSS.",
|
|
5551
|
+
"core-web-vitals": "Static hints in JSX/TSX related to Core Web Vitals (e.g. LCP-friendly images, main-thread hygiene). Not a substitute for real field metrics.",
|
|
5552
|
+
"ai-assisted-strict": "When the PR is AI-assisted or code is marked with @frontguard-ai decorators, scans those regions for risky patterns (eval, XSS sinks, etc.) and a few AST heuristics (e.g. discarded hot loops).",
|
|
5553
|
+
"pr-hygiene": "Validates PR metadata when CI provides PR context: description length, checklist items, and similar hygiene rules from config.",
|
|
5554
|
+
"pr-size": "Compares PR diff size (lines/files) against configured budgets to discourage oversized changes.",
|
|
5555
|
+
"ts-any-delta": "Diffs the branch against a base ref and counts newly added uses of the TypeScript any type. Helps stop gradual loss of type safety.",
|
|
5556
|
+
"custom-rules": "Runs optional file/content rules you define in FrontGuard config (regex or structured checks on paths). Skipped when no rules are configured."
|
|
5557
|
+
};
|
|
5558
|
+
function getCheckDescription(checkId) {
|
|
5559
|
+
return CHECK_DESCRIPTIONS[checkId] ?? `FrontGuard check "${checkId}". See your frontguard config and docs for behavior.`;
|
|
5560
|
+
}
|
|
5561
|
+
|
|
5324
5562
|
// src/report/html-report.ts
|
|
5325
5563
|
function parseLineHint(detail) {
|
|
5326
5564
|
if (!detail) return 0;
|
|
@@ -5360,6 +5598,205 @@ function statusDot(r4) {
|
|
|
5360
5598
|
return '<span class="dot dot-block" title="Blocking"></span>';
|
|
5361
5599
|
return '<span class="dot dot-warn" title="Issues"></span>';
|
|
5362
5600
|
}
|
|
5601
|
+
var CHECKS_TABLE_STYLES = `
|
|
5602
|
+
table.results {
|
|
5603
|
+
width: 100%;
|
|
5604
|
+
border-collapse: collapse;
|
|
5605
|
+
font-size: 0.875rem;
|
|
5606
|
+
background: var(--surface);
|
|
5607
|
+
border-radius: var(--radius);
|
|
5608
|
+
overflow: hidden;
|
|
5609
|
+
border: 1px solid var(--border);
|
|
5610
|
+
box-shadow: var(--shadow);
|
|
5611
|
+
}
|
|
5612
|
+
table.results th, table.results td {
|
|
5613
|
+
padding: 0.55rem 0.85rem;
|
|
5614
|
+
text-align: left;
|
|
5615
|
+
border-bottom: 1px solid var(--border);
|
|
5616
|
+
}
|
|
5617
|
+
table.results tr:last-child td { border-bottom: none; }
|
|
5618
|
+
table.results thead th {
|
|
5619
|
+
background: #f1f5f9;
|
|
5620
|
+
color: var(--muted);
|
|
5621
|
+
font-weight: 600;
|
|
5622
|
+
font-size: 0.72rem;
|
|
5623
|
+
text-transform: uppercase;
|
|
5624
|
+
letter-spacing: 0.04em;
|
|
5625
|
+
}
|
|
5626
|
+
.td-icon { width: 2rem; vertical-align: middle; }
|
|
5627
|
+
.td-check { vertical-align: middle; }
|
|
5628
|
+
.td-num, .td-time { color: var(--muted); font-variant-numeric: tabular-nums; }
|
|
5629
|
+
.check-title-cell {
|
|
5630
|
+
display: inline-flex;
|
|
5631
|
+
align-items: center;
|
|
5632
|
+
gap: 0.35rem;
|
|
5633
|
+
flex-wrap: nowrap;
|
|
5634
|
+
}
|
|
5635
|
+
.check-name { font-weight: 600; }
|
|
5636
|
+
.check-info-wrap {
|
|
5637
|
+
position: relative;
|
|
5638
|
+
display: inline-flex;
|
|
5639
|
+
align-items: center;
|
|
5640
|
+
flex-shrink: 0;
|
|
5641
|
+
}
|
|
5642
|
+
.check-info {
|
|
5643
|
+
display: inline-flex;
|
|
5644
|
+
align-items: center;
|
|
5645
|
+
justify-content: center;
|
|
5646
|
+
width: 1.125rem;
|
|
5647
|
+
height: 1.125rem;
|
|
5648
|
+
padding: 0;
|
|
5649
|
+
margin: 0;
|
|
5650
|
+
border: 1px solid var(--border);
|
|
5651
|
+
border-radius: 50%;
|
|
5652
|
+
background: #f1f5f9;
|
|
5653
|
+
color: var(--muted);
|
|
5654
|
+
font-size: 0.62rem;
|
|
5655
|
+
font-weight: 700;
|
|
5656
|
+
font-style: normal;
|
|
5657
|
+
line-height: 1;
|
|
5658
|
+
cursor: help;
|
|
5659
|
+
flex-shrink: 0;
|
|
5660
|
+
}
|
|
5661
|
+
.check-info:hover,
|
|
5662
|
+
.check-info:focus-visible {
|
|
5663
|
+
border-color: var(--accent);
|
|
5664
|
+
color: var(--accent);
|
|
5665
|
+
background: var(--accent-soft);
|
|
5666
|
+
outline: none;
|
|
5667
|
+
}
|
|
5668
|
+
.check-tooltip {
|
|
5669
|
+
position: absolute;
|
|
5670
|
+
left: 50%;
|
|
5671
|
+
bottom: calc(100% + 8px);
|
|
5672
|
+
transform: translateX(-50%);
|
|
5673
|
+
min-width: 12rem;
|
|
5674
|
+
max-width: min(22rem, 86vw);
|
|
5675
|
+
padding: 0.55rem 0.65rem;
|
|
5676
|
+
background: var(--text);
|
|
5677
|
+
color: #f8fafc;
|
|
5678
|
+
font-size: 0.78rem;
|
|
5679
|
+
font-weight: 400;
|
|
5680
|
+
line-height: 1.45;
|
|
5681
|
+
border-radius: 6px;
|
|
5682
|
+
box-shadow: 0 4px 14px rgba(15, 23, 42, 0.18);
|
|
5683
|
+
z-index: 50;
|
|
5684
|
+
opacity: 0;
|
|
5685
|
+
visibility: hidden;
|
|
5686
|
+
pointer-events: none;
|
|
5687
|
+
transition: opacity 0.12s ease, visibility 0.12s ease;
|
|
5688
|
+
text-align: left;
|
|
5689
|
+
}
|
|
5690
|
+
.check-info-wrap:hover .check-tooltip,
|
|
5691
|
+
.check-info-wrap:focus-within .check-tooltip {
|
|
5692
|
+
opacity: 1;
|
|
5693
|
+
visibility: visible;
|
|
5694
|
+
}
|
|
5695
|
+
.check-tooltip::after {
|
|
5696
|
+
content: '';
|
|
5697
|
+
position: absolute;
|
|
5698
|
+
top: 100%;
|
|
5699
|
+
left: 50%;
|
|
5700
|
+
margin-left: -6px;
|
|
5701
|
+
border: 6px solid transparent;
|
|
5702
|
+
border-top-color: var(--text);
|
|
5703
|
+
}
|
|
5704
|
+
.dot {
|
|
5705
|
+
display: inline-block;
|
|
5706
|
+
width: 8px;
|
|
5707
|
+
height: 8px;
|
|
5708
|
+
border-radius: 50%;
|
|
5709
|
+
}
|
|
5710
|
+
.dot-ok { background: var(--ok); }
|
|
5711
|
+
.dot-warn { background: var(--warn); }
|
|
5712
|
+
.dot-block { background: var(--block); }
|
|
5713
|
+
.dot-skip { background: #cbd5e1; }
|
|
5714
|
+
`;
|
|
5715
|
+
function renderCheckTableRows(results) {
|
|
5716
|
+
return results.map((r4) => {
|
|
5717
|
+
const status = r4.skipped ? `Skipped \u2014 ${escapeHtml(r4.skipped)}` : r4.findings.length === 0 ? "Pass" : `${r4.findings.length} issue(s)`;
|
|
5718
|
+
const help = escapeHtml(getCheckDescription(r4.checkId));
|
|
5719
|
+
const ariaWhat = escapeHtml(`What does the ${r4.checkId} check do?`);
|
|
5720
|
+
const checkTitle = `<span class="check-title-cell"><strong class="check-name">${escapeHtml(r4.checkId)}</strong><span class="check-info-wrap"><button type="button" class="check-info" title="${help}" aria-label="${ariaWhat}">i</button><span class="check-tooltip" role="tooltip">${help}</span></span></span>`;
|
|
5721
|
+
return `<tr><td class="td-icon">${statusDot(r4)}</td><td class="td-check">${checkTitle}</td><td class="td-status">${status}</td><td class="td-num">${r4.skipped ? "\u2014" : r4.findings.length}</td><td class="td-time">${formatDuration(r4.durationMs)}</td></tr>`;
|
|
5722
|
+
}).join("\n");
|
|
5723
|
+
}
|
|
5724
|
+
function buildChecksSnapshotHtml(p2) {
|
|
5725
|
+
const { riskScore, mode, results, warns, infos, blocks } = p2;
|
|
5726
|
+
const modeLabel = mode === "enforce" ? "Enforce" : "Warn only";
|
|
5727
|
+
const riskClass = riskScore === "LOW" ? "risk-low" : riskScore === "MEDIUM" ? "risk-med" : "risk-high";
|
|
5728
|
+
const checkRows = renderCheckTableRows(results);
|
|
5729
|
+
return `<!DOCTYPE html>
|
|
5730
|
+
<html lang="en">
|
|
5731
|
+
<head>
|
|
5732
|
+
<meta charset="utf-8" />
|
|
5733
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
5734
|
+
<title>FrontGuard \u2014 Checks</title>
|
|
5735
|
+
<style>
|
|
5736
|
+
:root {
|
|
5737
|
+
--bg: #f8fafc;
|
|
5738
|
+
--surface: #ffffff;
|
|
5739
|
+
--text: #0f172a;
|
|
5740
|
+
--muted: #64748b;
|
|
5741
|
+
--border: #e2e8f0;
|
|
5742
|
+
--accent: #4f46e5;
|
|
5743
|
+
--accent-soft: #eef2ff;
|
|
5744
|
+
--block: #dc2626;
|
|
5745
|
+
--warn: #d97706;
|
|
5746
|
+
--info: #0284c7;
|
|
5747
|
+
--ok: #16a34a;
|
|
5748
|
+
--radius: 10px;
|
|
5749
|
+
--shadow: 0 1px 3px rgba(15, 23, 42, 0.06);
|
|
5750
|
+
}
|
|
5751
|
+
* { box-sizing: border-box; }
|
|
5752
|
+
body {
|
|
5753
|
+
margin: 0;
|
|
5754
|
+
padding: 1.25rem 1.5rem 1.5rem;
|
|
5755
|
+
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
|
5756
|
+
background: var(--bg);
|
|
5757
|
+
color: var(--text);
|
|
5758
|
+
line-height: 1.55;
|
|
5759
|
+
font-size: 15px;
|
|
5760
|
+
max-width: 920px;
|
|
5761
|
+
}
|
|
5762
|
+
.brand {
|
|
5763
|
+
font-size: 0.75rem;
|
|
5764
|
+
font-weight: 600;
|
|
5765
|
+
letter-spacing: 0.12em;
|
|
5766
|
+
text-transform: uppercase;
|
|
5767
|
+
color: var(--muted);
|
|
5768
|
+
margin-bottom: 0.35rem;
|
|
5769
|
+
}
|
|
5770
|
+
.h2 {
|
|
5771
|
+
font-size: 1rem;
|
|
5772
|
+
font-weight: 600;
|
|
5773
|
+
margin: 0 0 0.5rem;
|
|
5774
|
+
color: var(--text);
|
|
5775
|
+
letter-spacing: -0.02em;
|
|
5776
|
+
}
|
|
5777
|
+
.snap-meta {
|
|
5778
|
+
font-size: 0.8rem;
|
|
5779
|
+
color: var(--muted);
|
|
5780
|
+
margin: 0 0 0.85rem;
|
|
5781
|
+
}
|
|
5782
|
+
.snap-meta strong { color: var(--text); font-weight: 600; }
|
|
5783
|
+
.risk-low { color: var(--ok); }
|
|
5784
|
+
.risk-med { color: var(--warn); }
|
|
5785
|
+
.risk-high { color: var(--block); }
|
|
5786
|
+
${CHECKS_TABLE_STYLES}
|
|
5787
|
+
</style>
|
|
5788
|
+
</head>
|
|
5789
|
+
<body>
|
|
5790
|
+
<div class="brand">FrontGuard</div>
|
|
5791
|
+
<h2 class="h2">Checks</h2>
|
|
5792
|
+
<p class="snap-meta">Risk <strong class="${riskClass}">${riskScore}</strong> \xB7 ${escapeHtml(modeLabel)} \xB7 Blocking <strong>${blocks}</strong> \xB7 Warnings <strong>${warns}</strong> \xB7 Info <strong>${infos}</strong></p>
|
|
5793
|
+
<table class="results">
|
|
5794
|
+
<thead><tr><th></th><th>Check</th><th>Status</th><th>#</th><th>Time</th></tr></thead>
|
|
5795
|
+
<tbody>${checkRows}</tbody>
|
|
5796
|
+
</table>
|
|
5797
|
+
</body>
|
|
5798
|
+
</html>`;
|
|
5799
|
+
}
|
|
5363
5800
|
function renderFindingCard(cwd, r4, f4) {
|
|
5364
5801
|
const d3 = normalizeFinding(cwd, f4);
|
|
5365
5802
|
const sevClass = f4.severity === "block" ? "sev-block" : f4.severity === "warn" ? "sev-warn" : "sev-info";
|
|
@@ -5384,10 +5821,7 @@ function buildHtmlReport(p2) {
|
|
|
5384
5821
|
} = p2;
|
|
5385
5822
|
const modeLabel = mode === "enforce" ? "Enforce" : "Warn only";
|
|
5386
5823
|
const riskClass = riskScore === "LOW" ? "risk-low" : riskScore === "MEDIUM" ? "risk-med" : "risk-high";
|
|
5387
|
-
const checkRows = results
|
|
5388
|
-
const status = r4.skipped ? `Skipped \u2014 ${escapeHtml(r4.skipped)}` : r4.findings.length === 0 ? "Pass" : `${r4.findings.length} issue(s)`;
|
|
5389
|
-
return `<tr><td class="td-icon">${statusDot(r4)}</td><td><strong class="check-name">${escapeHtml(r4.checkId)}</strong></td><td class="td-status">${status}</td><td class="td-num">${r4.skipped ? "\u2014" : r4.findings.length}</td><td class="td-time">${formatDuration(r4.durationMs)}</td></tr>`;
|
|
5390
|
-
}).join("\n");
|
|
5824
|
+
const checkRows = renderCheckTableRows(results);
|
|
5391
5825
|
const blockItems = sortFindings(
|
|
5392
5826
|
cwd,
|
|
5393
5827
|
results.flatMap(
|
|
@@ -5531,43 +5965,7 @@ function buildHtmlReport(p2) {
|
|
|
5531
5965
|
font-weight: 500;
|
|
5532
5966
|
background: #f1f5f9;
|
|
5533
5967
|
}
|
|
5534
|
-
|
|
5535
|
-
width: 100%;
|
|
5536
|
-
border-collapse: collapse;
|
|
5537
|
-
font-size: 0.875rem;
|
|
5538
|
-
background: var(--surface);
|
|
5539
|
-
border-radius: var(--radius);
|
|
5540
|
-
overflow: hidden;
|
|
5541
|
-
border: 1px solid var(--border);
|
|
5542
|
-
box-shadow: var(--shadow);
|
|
5543
|
-
}
|
|
5544
|
-
table.results th, table.results td {
|
|
5545
|
-
padding: 0.55rem 0.85rem;
|
|
5546
|
-
text-align: left;
|
|
5547
|
-
border-bottom: 1px solid var(--border);
|
|
5548
|
-
}
|
|
5549
|
-
table.results tr:last-child td { border-bottom: none; }
|
|
5550
|
-
table.results thead th {
|
|
5551
|
-
background: #f1f5f9;
|
|
5552
|
-
color: var(--muted);
|
|
5553
|
-
font-weight: 600;
|
|
5554
|
-
font-size: 0.72rem;
|
|
5555
|
-
text-transform: uppercase;
|
|
5556
|
-
letter-spacing: 0.04em;
|
|
5557
|
-
}
|
|
5558
|
-
.td-icon { width: 2rem; vertical-align: middle; }
|
|
5559
|
-
.td-num, .td-time { color: var(--muted); font-variant-numeric: tabular-nums; }
|
|
5560
|
-
.check-name { font-weight: 600; }
|
|
5561
|
-
.dot {
|
|
5562
|
-
display: inline-block;
|
|
5563
|
-
width: 8px;
|
|
5564
|
-
height: 8px;
|
|
5565
|
-
border-radius: 50%;
|
|
5566
|
-
}
|
|
5567
|
-
.dot-ok { background: var(--ok); }
|
|
5568
|
-
.dot-warn { background: var(--warn); }
|
|
5569
|
-
.dot-block { background: var(--block); }
|
|
5570
|
-
.dot-skip { background: #cbd5e1; }
|
|
5968
|
+
${CHECKS_TABLE_STYLES}
|
|
5571
5969
|
.panel {
|
|
5572
5970
|
background: var(--surface);
|
|
5573
5971
|
border: 1px solid var(--border);
|
|
@@ -5746,7 +6144,14 @@ function buildReport(stack, pr, results, options) {
|
|
|
5746
6144
|
lines,
|
|
5747
6145
|
llmAppendix: options?.llmAppendix ?? null
|
|
5748
6146
|
}) : null;
|
|
5749
|
-
|
|
6147
|
+
const checksSnapshotHtml = options?.emitChecksSnapshot === true ? buildChecksSnapshotHtml({
|
|
6148
|
+
riskScore,
|
|
6149
|
+
mode,
|
|
6150
|
+
results,
|
|
6151
|
+
warns,
|
|
6152
|
+
infos,
|
|
6153
|
+
blocks}) : null;
|
|
6154
|
+
return { riskScore, stack, pr, results, markdown, consoleText, html, checksSnapshotHtml };
|
|
5750
6155
|
}
|
|
5751
6156
|
function scoreRisk(blocks, warns, lines, files) {
|
|
5752
6157
|
let score = 0;
|
|
@@ -6507,11 +6912,25 @@ async function runFrontGuard(opts) {
|
|
|
6507
6912
|
mode,
|
|
6508
6913
|
llmAppendix,
|
|
6509
6914
|
cwd: opts.cwd,
|
|
6510
|
-
emitHtml: Boolean(opts.htmlOut)
|
|
6915
|
+
emitHtml: Boolean(opts.htmlOut),
|
|
6916
|
+
emitChecksSnapshot: Boolean(opts.checksSnapshotOut)
|
|
6511
6917
|
});
|
|
6512
6918
|
if (opts.htmlOut && report.html) {
|
|
6513
6919
|
await fs.writeFile(opts.htmlOut, report.html, "utf8");
|
|
6514
6920
|
}
|
|
6921
|
+
if (opts.checksSnapshotOut && report.checksSnapshotHtml) {
|
|
6922
|
+
const snapPath = path5.isAbsolute(opts.checksSnapshotOut) ? opts.checksSnapshotOut : path5.join(opts.cwd, opts.checksSnapshotOut);
|
|
6923
|
+
await fs.writeFile(snapPath, report.checksSnapshotHtml, "utf8");
|
|
6924
|
+
const fileUrl = pathToFileURL(snapPath).href;
|
|
6925
|
+
g.stderr.write(
|
|
6926
|
+
`
|
|
6927
|
+
FrontGuard: wrote checks snapshot HTML to ${snapPath} (screenshot this file for PR comments).
|
|
6928
|
+
Example: npx playwright screenshot "${fileUrl}" frontguard-checks.png
|
|
6929
|
+
Host the PNG at an HTTPS URL, then set FRONTGUARD_CHECKS_IMAGE_URL before generating the PR comment.
|
|
6930
|
+
|
|
6931
|
+
`
|
|
6932
|
+
);
|
|
6933
|
+
}
|
|
6515
6934
|
if (opts.prCommentOut) {
|
|
6516
6935
|
const snippet = formatBitbucketPrSnippet(report);
|
|
6517
6936
|
const abs = path5.isAbsolute(opts.prCommentOut) ? opts.prCommentOut : path5.join(opts.cwd, opts.prCommentOut);
|
|
@@ -6521,6 +6940,7 @@ async function runFrontGuard(opts) {
|
|
|
6521
6940
|
FrontGuard: wrote Bitbucket PR comment text to ${abs} (${snippet.length} bytes).
|
|
6522
6941
|
Use ONLY this file in your POST \u2026/pullrequests/{id}/comments payload (content.raw).
|
|
6523
6942
|
Do not post frontguard-report.md or captured stdout \u2014 that is the long markdown log.
|
|
6943
|
+
Optional: set FRONTGUARD_CHECKS_IMAGE_URL so the comment includes a checks summary image.
|
|
6524
6944
|
|
|
6525
6945
|
`
|
|
6526
6946
|
);
|
|
@@ -6573,6 +6993,10 @@ var run = defineCommand({
|
|
|
6573
6993
|
type: "string",
|
|
6574
6994
|
description: "Write interactive HTML report (use with CI artifacts; PR comment links to download)"
|
|
6575
6995
|
},
|
|
6996
|
+
checksSnapshotOut: {
|
|
6997
|
+
type: "string",
|
|
6998
|
+
description: "Write HTML with only the Checks table (screenshot \u2192 PNG \u2192 FRONTGUARD_CHECKS_IMAGE_URL in PR comment)"
|
|
6999
|
+
},
|
|
6576
7000
|
prCommentOut: {
|
|
6577
7001
|
type: "string",
|
|
6578
7002
|
description: "Write short Markdown for Bitbucket PR comment (summary + pipeline link for HTML artifact)"
|
|
@@ -6585,6 +7009,7 @@ var run = defineCommand({
|
|
|
6585
7009
|
enforce: Boolean(args.enforce),
|
|
6586
7010
|
append: typeof args.append === "string" ? args.append : null,
|
|
6587
7011
|
htmlOut: typeof args.htmlOut === "string" ? args.htmlOut : null,
|
|
7012
|
+
checksSnapshotOut: typeof args.checksSnapshotOut === "string" ? args.checksSnapshotOut : null,
|
|
6588
7013
|
prCommentOut: typeof args.prCommentOut === "string" ? args.prCommentOut : null
|
|
6589
7014
|
});
|
|
6590
7015
|
}
|